The hard ways

WebSocket 协议 5~10 节

5. 使用帧去组织数据

5.1 概览

在 WebSocket 协议中,数据的传输使用一连串的帧。为了使得中间件不至于混淆(比如代理服务器)以及为了第 10.3 节将讨论安全原因,客户端必须将要发送到服务端的帧进行掩码,掩码将在第 5.3 节详细讨论。(注意,不管 WebSocket 有没有运行在 TLS 之上,都必须有掩码操作)服务端一旦接收到没有进行掩码的帧的话,必须关闭连接。这种情况下,服务端可以发送一个关闭帧,包含一个状态码 1002(协议错误 protocol error),相关定义在 Section 7.4.1。服务端不必对发送到客户端的任何帧进行掩码。如果客户端接收到了服务端的掩码后的帧,客户端必须关闭连接。在这个情况下,客户端可以向服务器发送关闭帧,包含状态码 1002(协议错误 protocol error),相关定义在 Section 7.4.1。(这些规则可能在将来技术说明中没有严格要求)

基础帧协议通过操作码(opcode)定义了一个帧类型,一个有效负荷长度,以及特定的位置存放 “扩展数据 Extension data” 和 “应用数据 Application data”,扩展数据和应用数据合起来定义了 “有效负荷数据 Payload data”。某些数位和操作码是保留的,为了将来的使用。

在客户端和服务端完成了握手之后,以及任意一端发送的关闭帧(在第 5.5.1 节介绍)之前,客户端可以和服务端都可以在任何时间发送数据帧。

基础帧协议

这一节中将使用 ABNF 详细定义数据传输的格式。(注意,和这文档中的其他 ABNF 不同,这一节中 ABNF 操作的是一组数位。每一组数位的长度将以注释的形式存在。当数据在网络中传输时,最高有效位是在 ABNF 的最左边(大端序))。下面的文本图像可以给出关于帧的一个高层概览。如果下面的文本插图和后的 ABNF 描述发送冲突时,以插图为准。

  0                   1                   2                   3
  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
 +-+-+-+-+-------+-+-------------+-------------------------------+
 |F|R|R|R| opcode|M| Payload len |    Extended payload length    |
 |I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
 |N|V|V|V|       |S|             |   (if payload len==126/127)   |
 | |1|2|3|       |K|             |                               |
 +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
 |     Extended payload length continued, if payload len == 127  |
 + - - - - - - - - - - - - - - - +-------------------------------+
 |                               |Masking-key, if MASK set to 1  |
 +-------------------------------+-------------------------------+
 | Masking-key (continued)       |          Payload Data         |
 +-------------------------------- - - - - - - - - - - - - - - - +
 :                     Payload Data continued ...                :
 + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
 |                     Payload Data continued ...                |
 +---------------------------------------------------------------+
  • FIN: 1 个数位(bit)

    标记这个帧是不是消息中的最后一帧。第一个帧也可以是最后一帧。

  • RSV1,RSV2,RSV3: 各 1 个数位

    必须是 0,除非有扩展赋予了这些数位非 0 值的意义。如果接收到了一个非 0 的值并且没有扩展赋予这些非 0 值的意义,那么接收端需要标记连接为失败。

  • 操作码:4 个数位 定义了如何解释 “有效负荷数据 Payload data”。如果接收到一个未知的操作码,接收端必须标记 WebSocket 为失败。定义了如下的操作码:

    • %x0 表示这是一个继续帧(continuation frame)
    • %x1 表示这是一个文本帧 (text frame)
    • %x2 表示这是一个二进制帧 (binary frame)
    • %x3-7 为将来的非控制帧(non-control frame)而保留的
    • %x8 表示这是一个连接关闭帧 (connection close)
    • %x9 表示这是一个 ping 帧
    • %xA 表示这是一个 pong 帧
    • xB-F 为将来的控制帧(control frame)而保留的
  • 掩码标识 Mask:1 个数位

    定义了 “有效负荷数据” 是否是被掩码的。如果被设置为 1,那么在 masking-key 部分将有一个掩码钥匙(masking key),并且使用这个掩码钥匙去将 “有效负荷数据” 进行反掩码操作(第 5.3 节描述)。所有的由客户端发往服务端的帧此数位都被设置成 1。

  • 有效负荷长度(Payload length): 7、7+16 或者 7+64 数位

    表示了 “有效负荷数据 Payload data” 的长度,以字节为单位:如果是 0-125,那么就直接表示了负荷长度。如果是 126,那么接下来的两个字节表示的 16 位无符号整型数则是负荷长度。如果是 127,则接下来的 8 个字节表示的 64 位无符号整型数则是负荷长度。表示长度的数值的字节是按网络字节序(network byte order 即大端序)表示的。注意在所有情况下,必须使用最小的负荷长度,比如,对于一个 124 字节长度的字符串,长度不可以编码成 126,0,124。负荷长度是 “扩展数据 Extension data” 长度 + “应用数据Application data” 长度 。“扩展数据” 的长度可以是 0,那么此时 “应用数据” 的长度就是负荷长度。

  • 掩码钥匙 Masking key:0 或者 4 个数位

    所有由客户端发往服务端的帧中的内容都必须使用一个 32 位的值进行掩码。这个字段有值的时候(占 4 个数位)仅当掩码标识位设置成了 1,如果掩码标识位设置为 0,则此字段没有值(占 0 个数位)。对于进一步掩码操作,见第 5.3 节。

  • 有效负荷数据 Payload data:(x+y) 字节 byte

    “有效负荷数据” 的定义是 “扩展数据” 联合 “应用数据”。

  • 扩展数据 Extension data: x 字节

    “扩展数据是” 0 个字节的,除非协商了一个扩展。任何的扩展都必须提供 “扩展数据” 的长度或者该长度应该如何计算,以及在握手阶段如何使用 “扩展数据” 进行扩展协商。如果 “扩展数据” 存在,那么它的长度被包含在了负荷长度中。

  • 应用数据 Application data: y字节

    可以是任意的 “应用数据”,它在一个帧的范围内紧接着 “扩展数据”。“应用数据” 的长度等于负荷长度减去 “扩展数据” 的长度

基础帧协议通过接下来的 ABNF RFC5234 来定义其形式。一个重要的注意点就是下面的 ABNF 表示的是二进制数据,而不是其表面上的字符串。比如, %x0 和 %x1 各表示一个数位,数位上的值为 0 和 1,而不是表示的字符 “0” 和 “1” 的 ASCII 编码。RFC5234 没有定义 ABNF 的字符编码。在这里,ABNF 被特定了使用的是二进制编码,这里二进制编码的意思就是每一个值都被编码成具有特定数量的数位,具体的数量因不同的字段而异。

ws-frame                = frame-fin           ; 1 bit in length
                          frame-rsv1          ; 1 bit in length
                          frame-rsv2          ; 1 bit in length
                          frame-rsv3          ; 1 bit in length
                          frame-opcode        ; 4 bits in length
                          frame-masked        ; 1 bit in length
                          frame-payload-length   ; either 7, 7+16,
                                                 ; or 7+64 bits in
                                                 ; length
                          [ frame-masking-key ]  ; 32 bits in length
                          frame-payload-data     ; n*8 bits in
                                                 ; length, where
                                                 ; n >= 0

frame-fin               = %x0 ; more frames of this message follow
                        / %x1 ; final frame of this message
                              ; 1 bit in length

frame-rsv1              = %x0 / %x1
                          ; 1 bit in length, MUST be 0 unless
                          ; negotiated otherwise

frame-rsv2              = %x0 / %x1
                          ; 1 bit in length, MUST be 0 unless
                          ; negotiated otherwise

frame-rsv3              = %x0 / %x1
                          ; 1 bit in length, MUST be 0 unless
                          ; negotiated otherwise

frame-opcode            = frame-opcode-non-control /
                          frame-opcode-control /
                          frame-opcode-cont

frame-opcode-cont       = %x0 ; frame continuation

frame-opcode-non-control= %x1 ; text frame
                        / %x2 ; binary frame
                        / %x3-7
                        ; 4 bits in length,
                        ; reserved for further non-control frames

frame-opcode-control    = %x8 ; connection close
                        / %x9 ; ping
                        / %xA ; pong
                        / %xB-F ; reserved for further control
                                ; frames
                                ; 4 bits in length


frame-masked            = %x0
                            ; frame is not masked, no frame-masking-key
                            / %x1
                            ; frame is masked, frame-masking-key present
                            ; 1 bit in length

frame-payload-length    = ( %x00-7D )
                        / ( %x7E frame-payload-length-16 )
                        / ( %x7F frame-payload-length-63 )
                        ; 7, 7+16, or 7+64 bits in length,
                        ; respectively

frame-payload-length-16 = %x0000-FFFF ; 16 bits in length

frame-payload-length-63 = %x0000000000000000-7FFFFFFFFFFFFFFF
                        ; 64 bits in length

frame-masking-key       = 4( %x00-FF )
                          ; present only if frame-masked is 1
                          ; 32 bits in length

frame-payload-data      = (frame-masked-extension-data
                           frame-masked-application-data)
                        ; when frame-masked is 1
                          / (frame-unmasked-extension-data
                            frame-unmasked-application-data)
                        ; when frame-masked is 0

frame-masked-extension-data     = *( %x00-FF )
                        ; reserved for future extensibility
                        ; n*8 bits in length, where n >= 0

frame-masked-application-data   = *( %x00-FF )
                        ; n*8 bits in length, where n >= 0

frame-unmasked-extension-data   = *( %x00-FF )
                        ; reserved for future extensibility
                        ; n*8 bits in length, where n >= 0

frame-unmasked-application-data = *( %x00-FF )
                        ; n*8 bits in length, where n >= 0

5.3 客户端到服务端掩码

一个被掩码的帧需要将掩码标识位(第 5.2 节定义)设置为 1。

掩码钥匙 masking key 整个都在帧中,就像第 5.2 节定义的。它用于对 “有效负荷数据” 进行掩码操作,包括 “扩展数据” 和 “应用数据”。

掩码钥匙由客户端随机选取一个 32 位的值。在每次准备对帧进行掩码操作时,客户端必须选择在可选的 32 位数值集合中选取一个新的掩码钥匙。掩码钥匙的值需要是不可被预测的;因此,掩码钥匙必须来源于一个具有很强保密性质的生成器,并且 服务器/代理 不能够轻易的预测到一连串的帧中使用的掩码钥匙。不可预测的掩码钥匙可以防止恶意程序在帧的传输过程中探测到掩码钥匙的内容。RFC4086 具体讨论了为什么对于一个安全性比较敏感的应用程序需要使用一个很强保密性质的生成器。

掩码不会影响 “有效负载数据” 的长度。为了将掩码后的数据进行反掩码,或者倒过来,可以使用下面的算法。同样的算法适用于不同方向发来的帧,比如,对于掩码和反掩码使用相同的步骤。

传输数据中的每 8 个数位的字节 i (transformed-octet-i),生成方式是通过原数据中的每 8 个数位的字节 i (original-octet-i)与以 i 与 4 取模后的数位为索引的掩码钥匙中的 8 为字节 j(masking-key-octet-j) 进行异或(XOR)操作:

j = i MOD 4
transformed-octet-i = original-octet-i XOR masking-key-octet-j

负载的长度不包括掩码钥匙的长度,它是 “有效负载数据 Payload data” 的长度,比如,位于掩码钥匙后的字节的长度。

5.4 消息碎片化

消息碎片化的目的就是允许发送那些在发送时不知道其缓冲的长度的消息。如果消息不能被碎片化,那么一端就必须将消息整个地载入内存缓冲,这样在发送消息前才可以计算出消息的字节长度。有了碎片化的机制,服务端或者中间件就可以选取其适用的内存缓冲长度,然后当缓冲满了之后就发送一个消息碎片。

碎片机制带来的另一个好处就是可以方便实现多路复用。没有多路复用的话,就需要将一整个大的消息放在一个逻辑通道中发送,这样会占用整个输出通道。多路复用需要可以将消息分割成小的碎片,使这些小的碎片可以共享输出通道。(注意多路复用的扩展在这片文档中并没有进行描述)

除非运用了特定的扩展,否则帧是没有特定的语义的。在客户端和服务端协商了某个扩展,或者客户端和服务端没有协商扩展的情况下,中间件都有可能将帧进行 合并/分隔。也就是说,在客户端和服务端没有协商某个扩展时,双方都不应该猜测帧与帧之间的边界。注:这里的某个扩展的意思就是赋予了帧特定的语义的扩展,比如多路复用扩展。

下面的规则解释了如何进行碎片化:

  • 一个没有被碎片化的消息只包含一个帧,并且帧的 FIN 数位被设置为 1,且操作码 opcode 不为 0。

  • 一个碎片化的消息包含了一个 FIN 未被置为 0 的帧,且这个帧的 opcode 不为 0,在这个帧之后,将有 0 个或者多个 FIN 为 0 且 opcode 为 0 的帧,最后以一个 FIN 为 1 和 opcode 为 0 的帧结束。对于一个碎片化后的消息,它的有效负荷就等于将碎片化后的帧的有效负荷按顺序连接起来;不过当存在扩展时,这一点就不一定正确了,因为扩展可能会设置帧的 “扩展数据”。在没有 “扩展数据” 的情况下,下面的例子演示了碎片化是如何工作的。

    例子:对于一个以三个帧发送的文本消息,其第一个帧的 opcode 是 0x1 并且 FIN 位是 0,第二个帧的 opcode 是 0x0 且 FIN 位是 0,第三个帧的 opcode 是 0x0 且 FIN 位是 1。

  • 控制帧(见第 5.5 节),可能会夹杂在消息帧之间。控制帧是不能被碎片化的。

  • 消息帧必须以其被发送时的顺序传递到接收端。

  • 不同消息的消息帧之间不可以相互夹杂,除非协商了一个定义了如何解释这种夹杂行为的扩展。

  • 发送端可以创建任意大小的非控制帧。

  • 客户端和服务端必须支持发送和接受碎片化或者非碎片化的消息。

  • 一个控制帧是不可以被碎片化的,中间件必须不可以试图将控制帧进行碎片化。

  • 如果帧中使用了 RSV 数位,但是中间件不理解其中的任意的 RSV 数位 的值时,它必须不可以改变消息的原有的碎片化帧。

  • 在中间件不能确定客户端和服务端进行了哪些扩展协商的情况下,中间件必须不可以修改原有的碎片化帧。

  • 最后,组成消息的所有帧都是相同的数据类型,在第一个帧中的 opcode 中指明。因为控制帧不能被碎片化,组成消息的碎片类型必须是文本、二进制、或者其他的保留类型。

注意:如果控制帧不能夹杂在消息帧的话,那么将导致 ping 的结果产生延迟,比如在处理了一个非常长的消息后才响应 ping 控制帧时。因此,要求在处理消息帧的期间可以响应控制帧。

重点注意:在没有扩展的情况下,接收端为了处理消息不是非得缓冲所有的帧。比如如果使用了 流API (streaming API),数据帧可以直接传递给应用层。不过这样假设并不一定在所有的扩展中都适用。

5.5 控制帧

控制帧是通过它的 opcode 的最高有效位是 1 去确定的。当前已经定义了的控制帧包括 0x8 (close)0x9 (Ping)0xA (Pong)。操作码 0xB-0xF 是为将来的控制帧保留的,目前尚未定义。

控制帧是为了在 WebSocket 中通信连接状态。控制帧可以夹杂在消息帧之间发送。

所有的控制帧的负载长度都必须是 125 字节,并且不能被碎片化。

关闭帧

关闭帧的操作码 opcode 是 0x8

关闭帧可以包含消息体(通过帧的 “应用数据” 部分)去表示关闭的原因,比如一端正在关闭服务,一端接收到的帧过大,或者一端接收到了不遵循格式的帧。如果有消息体的话,消息体的前两个字节必须是无符号的整型数(采用网络字节序),以此整型数去表示状态码 /code/ 定义在第 7.4 节。在两个字节的无符号整型数之后,可以跟上以 UTF-8 编码的数据表示 /reason/,/reason/ 数据的具体解释方式此文档并没有定义。并且 /reason/ 的内容不一定是人类可读的数据,只要是有利于发起连接的脚本进行调试就可以。因为 /reason/ 并不一定就是人类可读的,所以客户端必须不将此内容展示给最终用户。

客户端发送的每一个帧都必须按照第 5.3 节中的内容进行掩码。

应用程序在发送了关闭帧之后就不可以再发送其他数据帧了。

如果接收到关闭帧的一端之前没有发送过关闭帧的话,那么它必须发送一个关闭帧作为响应。(当发送一个关闭帧作为响应的时候,发送端通常在作为响应的关闭帧中采用和其接收到的关闭帧相同的状态码)。并且响应必须尽快的发送。一端可以延迟关闭帧的发送,比如一个重要的消息已经发送了一半,那么可以在消息的剩余部分发送完之后再发送关闭帧。但是作为首先发送了关闭帧,并在等待另一端进行关闭响应的那一端来说,并不一定保证其会继续处理数据内容。

在发送和接收到了关闭帧之后,一端就可以认为 WebSocket 连接已经关闭,并且必须关闭底层相关的 TCP 连接。如果是服务端首先发送了关闭帧,那么在接收到客户端返回的关闭帧之后,服务端必须立即关闭底层相关的 TCP 连接;但是如果是客户端首先发送了关闭帧,并接收到了服务端返回的关闭帧之后,可以选择其认为合适的时间关闭连接,比如,在一段时间内没有接收到服务端的 TCP 关闭握手。

如果客户端和服务端同时发送了关闭消息,那么它们两端都将会接收到来自对方的关闭消息,那么它们就可以认为 WebSocket 连接已经关闭,并且关闭底层相关的 TCP 连接。

5.5.2 Ping

Ping 帧的操作码是 0x9

Ping 帧也可以有 “应用数据”

一旦接收到了 Ping 帧,接收到的一端必须发送一个 Pong 帧作为响应,除非它已经接收到了关闭帧。响应的一端必须尽快的做出响应。Pong 帧定义在第 5.5.3 节。

一端可以在连接建立之后,到连接关闭之前的任意时间点发送 Ping 帧。

注意:Ping 帧的目的可以是保持连接(keepalive)或者是验证服务端是否还是有响应的。

5.5.3 Pong

Pong 帧的操作码是 0xA

第 5.5.2 节的要求同时适用于 Ping 帧和 Pong 帧。

Pong 帧的 “应用数据” 中的内容必须和其响应的 Ping 帧中的 “应用数据” 的内容相同。

如果一端接收到了 Ping 帧并且在没有来得及响应的时候又接收到了新的 Ping 帧,那么响应端可以选择最近的 Ping 帧作为响应的对象。

Pong 帧可以在未被主动请求的情况下发送给对方。这被认为是单向的心跳包。单向心跳包是得不到响应的。

5.6 数据帧

数据帧(比如,非控制帧)是通过操作码的最高有效位是 0 来确定的。当前已经定义的数据帧包括 0x1 (文本)0x2 (二进制)。操作码 0x3-0x7 是为了将来的非控制帧的使用而保留的。

数据帧承载了 “应用层 application-layer” 或者 “扩展层 extension-layer” 的数据。操作码决定了数据的表现形式。

  • 文本 Text

    “有效负载数据 Payload data” 是以 UTF-8 编码的文本。注意,作为整个文本消息的一部分的部分文本帧可能包含了部分的 UTF-8 序列;但是整个的消息的内容必须是一个有效的 UTF-8 序列。对于无效的 UTF-8 消息的处理在第 8.1 节中描述。

  • 二进制 Binary

    “有效负荷数据 Payload data” 是仅由应用层来决定的任意二进制内容。

5.7 例子

  • 一个单个帧的没有进行掩码的文本消息

    • 0x81 0x05 0x48 0x65 0x6c 0x6c 0x6f(消息内容为 “Hello”)
  • 一个单个帧的掩码后的消息

    • 0x81 0x85 0x37 0xfa 0x21 0x3d 0x7f 0x9f 0x4d 0x51 0x58(消息内容为 “ Hello”)
  • 一个碎片化的没有掩码的文本消息

    • 0x01 0x03 0x48 0x65 0x6c(消息内容为 “Hel”)
    • 0x80 0x02 0x6c 0x6f(消息内容为 “lo”)
  • 没有掩码的 Ping 请求和其掩码后的响应

    • 0x89 0x05 0x48 0x65 0x6c 0x6c 0x6f(消息体部分为 “Hello”,只不过是例子,可以为任意内容)
    • x8a 0x85 0x37 0xfa 0x21 0x3d 0x7f 0x9f 0x4d 0x51 0x58(消息体部分也是 “Hello”,和其响应的 Ping 相同)
  • 256 个字节的二进制消息,使用单个未掩码的帧

    • 0x82 0x7E 0x0100 [256 个字节的二进制数据]
  • 64 Kb 的二进制消息,使用单个未掩码的帧

    • 0x82 0x7F 0x0000000000010000 [65536 个字节的二进制数据]

5.8 扩展性

协议被设计为允许扩展,扩展可以在基础协议的功能上添加更多的功能。通信双方必须在握手期间完成扩展的协商。在这份技术说明中,为扩展提供使用的部分为:操作码 0x3 到 0x7、以及 0xB 到 0xF,“扩展数据 Extension data” 字段,frame-rsv1、frame-rsv2、frame-rsv3 这三个位于帧头部的数位。关于扩展协商的详细在第 9.1 节中讨论。下面的列表是关于扩展的预期使用形式,不过它既不完整也不规范:

  • “扩展数据” 可以放在 “应用数据” 之间,它们共同组成 “有效负荷数据”
  • 保留的数位可以为每一帧按需分配
  • 保留的操作码可以被定义
  • 如果需要更多的操作码的话,可以占用保留数位以为操作码提供更多的数位空间
  • 占用保留数位,或者在 “有效负荷数据” 之外定义 “扩展” 的操作码,以此获得更大的操作码表示空间,或者更多的区别每一帧的数位

6 发送和接收数据

6.1 发送数据

为了在 WebSocket 连接上发送由 /data/ 组成的 WebSocket 消息,发送端必须按下的步骤去执行:

  1. 发送端必须确定当前的 WebSocket 连接的状态是 OPEN(见 第 4.1 和 4.2 节)。在任何时间点,如果连接的状态改变了,那么发送端必须终止下面的步骤。

  2. 发送端必须使用 WebSocket 帧将 /data/ 按第 5.2 节中描述的形式包裹起来。如果数据太大,或者在发送时不能整个地获取需发送数据,那么发送端可以按照第 5.4 节中描述的,将数据分割成一连串的帧进行发送。

  3. 包含数据的第一个帧的操作码必须设置为适当的数据类型,以便接收端可以确定用文本还是二进制来解释其接收到的数据,数据类型定义在第 5.2 节。

  4. 在消息的最后一个包含数据的帧中必须将其 FIN 设置为 1,相关定义在第 5.2 节。

  5. 如果数据是由客户端发送的,那么数据在发送前必须按照第 5.2 中定义的方式进行掩码。

  6. 如果连接中进行了扩展协商,那么额外的扩展相关的处理将会应用到帧上。

  7. 帧必须经由 WebSocket 底层相关的网络连接发送。

6.2 接收数据

为了接收 WebSocket 数据,接收端必须监听底层相关的网络连接。接收到的数据必须按照第 5.2 节中定义的格式进行解析。如果接收到的是一个控制帧,那么必须按照第 5.5 节中的定义去处理。一旦接收到第 5.6 节中定义的数据帧,接收端必须注意数据帧的类型 /type/,这点根据帧的 opcode,定义在第 5.2 节。“应用数据 Application data” 被定义为消息的数据 /data/。如果帧是一个没有被碎片化的帧,定义在第 5.4 节,那么就说明一个消息已经被完全接收了,即知道了其类型 /type/ 和数据 /data/。如果帧是碎片化消息的一部分,那么其随后的帧的 “应用数据” 连接在一起组成消息的数据 /data/。当最后一个碎片化的帧被接收时,也就是帧的 FIN 位为 1 时,表明一个 WebSocket 消息已经被完全接收了,其数据 /data/ 就是所有相关碎片化的帧的 “应用数据” 连接到一起的值,而 /type/ 就是第一个或者其他组成消息的碎片化帧的操作码。之后的帧必须被解释为属于一个新的消息。

扩展(第 9 节)可能会更改数据被读取的方式,特别是如何界定消息之间的边界。扩展在有效负荷中的 “应用数据” 之前添加的 “扩展数据” 也可能会修改 “应用数据” 的内容(比如进行了压缩)。

服务端必须将来自客户端的帧进行反掩码,操作定义在第 5.3 节。

7 关闭连接

7.1 定义

7.1.1 关闭 WebSocket 连接

为了关闭 WebSocket 连接,一端可以关闭底层的 TCP 连接。一端在关闭连接的时候必须干净的关闭,比如 TLS 会话,尽可能的丢弃所有已经接收但是尚未处理的字节。一端可以在需要的时候以任意的理由去关闭连接,比如在收到攻击时。

底层的 TCP 连接,在一般情况下应该由服务端先进行关闭,而客户端则需要在一段时间内等待服务端的 TCP 关闭,如果超过了客户端的等待时间,客户端则可以关闭 TCP 连接。

一个以使用 Berkeley sockets 的 C 语言的例子演示如何干净的关闭连接:首先一端需要调用对 socket 调用 shutdown() 函数,并以 SHUT_WR 为函数的参数,然后调用 recv() 函数直到其返回值为 0,最后调用 close() 函数关闭 socket。

7.1.2 开始 WebSocket 关闭握手

为了关闭开始 WebSocket 关闭握手,需要关闭的一端必须选择一个状态码(第 7.4 节)/code/ 和可选的关闭原因 (第 7.1.6 节)/reason/,然后按照第 5.5.1 节中的描述发送一个关闭帧,帧的状态码以及原因就是之前选取的 /code/ 和 /reason/。一旦一端发送并接收到了关闭帧,就可以按照第 7.1.1 节中定义的内容关闭 WebSocket 连接。

7.1.3 WebSocket 关闭握手已经开始

一旦任何一端发送或者接收到关闭帧,就表明 WebSocket 关闭握手已经开始,并且 WebSocket 连接的状态变为 CLOSING。

7.1.4 WebSocket 连接已经关闭

当底层的 TCP 连接已经关闭时,就表明 WebSocket 连接已经关闭,并且 WebSocket 连接的状态变为 CLOSED。如果 TCP 连接在 WebSocket 关闭握手完成之后才进行关闭,就说明关闭是干净(cleanly)的。否则的话就说明 WebSocket 连接已经关闭,但不是干净地(cleanly)。

7.1.5 WebSocket 连接关闭代码

与第 5.5.1 节和第 7.4 节中定义的一样,一个关闭帧可以包含一个关闭状态码,以此表明关闭的原因。WebSocket 的关闭可以由任意一端发起,或者同时发起。返回的关闭帧的状态码与接收到的关闭帧的状态码相同。如果关闭帧没有包含状态码,那么就认为其状态码是 1005。如果一端发现 WebSocket 连接已经关闭但是没有收到关闭帧,那么就认为此时的状态码是 1006。

注意:两端的关闭帧的状态码不必相同。比如,如果远程的一端发送了一个关闭帧,但是本地的应用程序还没有读取位于接收缓存中的关闭帧,并且应用程序也发送了一个关闭帧,那么两端都将会达到 “发送了” 和 “接收到” 关闭帧的状态。每一端都会看到来自另一端的具有不同状态码的关闭帧。因此,两端可以不必要求发送和接收到的关闭帧的状态码是相同的,这样两端就可以大概同时进行 WebSocket 连接的关闭了。

7.1.6 WebSocket 连接关闭原因

与第 5.5.1 节和第 7.4 节中定义的相同,关闭帧可以包含一个状态码,并且在状态码之后可以跟随以 UTF-8 编码的数据,具体这些数据应该如何被解释依赖于对端的实现,本协议并没有明确的定义。每一端都可以发起 WebSocket 关闭,或者同时发起。WebSocket 连接关闭原因的定义就是跟随在关闭状态码之后的以 UTF-8 编码的数据,响应的关闭帧中的 /reason/ 内容来自请求的关闭帧中 /reason/,并与之相同。如果没有定义这些 UTF-8 数据,那么关闭的原因就是空字符串。

注意:遵循第 7.1.5 节中描述的逻辑,两端不必要求发送和接收的关闭帧的 /reason/ 是相同的。

7.1.7 将 WebSocket 连接标记为失败

因为某种算法或者特定的需求使得一端需要将 WebSocket 连接表示位失败。为了达到这个目的,客户端必须关闭 WebSocket 连接,并且可以将问题以适当的方式反馈给用户(对于开发者来说可能非常重要)。同样的,服务端为了达到这个目的也必须关闭 WebSocket 连接,并且使用日志记录下发生的问题。

如果在希望将 WebSocket 连接标记为失败之前,WebSocket 连接已经建立的话,那么一端在关闭 WebSocket 连接之前应该发送关闭帧,并带上适当的状态码(第 7.4 节)。如果一端认为另一端不可能有能力去接受和处理关闭帧时,比如 WebSocket 连接尚未建立,那么可以省略发送关闭帧的过程。如果一端标记了 WebSocket 连接为失败的,那么它不可以再接受和处理来自远程的数据(包括响应一个关闭帧)。

除了上面的情况或者应用层需要(比如,使用了 WebSocket API 的脚本),客户端不应该关闭连接。

7.2 异常关闭

7.2.1 客户端发起的关闭

因为某种算法或者在开始握手的实际运作过程中,需要标记 WebSocket 连接为失败。为了达到这个目的,客户端必须按照第 7.1.7 节中描述的内容将 WebSocket 连接标记为失败。

如果在任意时间点,底层的传输层连接发送了丢失,那么客户端必须将 WebSocket 连接标记为失败。

除了上面的情况或者特定的应用层需要(比如,使用了 WebSocket API 的脚本),客户端不可以关闭连接。

7.2.2 服务端发起的关闭

因为某种算法或者在握手期间终止 WebSocket 连接,服务端必须按照第 7.1.1 节的描述去关闭 WebSocket 连接。

7.2.3 从异常中恢复

异常关闭可能有很多的原因引起。比如一个短暂的错误导致的异常关闭,在这种情况下,通过重连可以使用一个没有问题的连接,然后继续正常的操作。然而异常也可能是一个由非短暂的问题引起的,如果所有发布的客户端在经历了一个异常关闭之后,立刻不断的试图向服务器发起重连,如果有大量的客户端在试图重连的话,那么服务器将有可能面对拒绝服务攻击(denial-of-service attack)。这样造成的结果就是服务将无法在短期内恢复。

为了防止这个问题出现,客户端应该在发生了异常关闭之后进行重连时使用一些补偿机制。

第一个重连应该延迟,在一个随机时间后进行。产生用于延迟的随机时间的参数由客户端去决定,初始的重连延迟可以在 0 到 5 秒之间随机选取。客户端可以根据实际应用的情况去决定具体的随机值。

如果第一次的重连失败,那么接下来的重连应该使用一个更长的延迟,可以使用一些已有的方法,比如 truncated binary exponential backoff

7.3 连接的一般关闭

服务端可以在其需求的时候对 WebSocket 连接进行关闭。客户端不应该随意的关闭 WebSocket 连接。当需要进行关闭的时候,需要遵循第 7.1.2 节中定义的过程。

状态码

当关闭已经建立的连接时(比如在握手完成后发送关闭帧),请求关闭的一端必须表明关闭的原因。如何解释原因,以及对于原因应该采取什么动作,都是这份技术说明中没有定义的。这份技术说明中定义了一组预定义的状态码,以及扩展、框架、最终应用程序使用的状态码范围。状态码相关的原因 /reason/ 在关闭帧中是可选的。

7.4.1 已定义的状态码

当发送关闭帧的时候,一端可以采用下面的预定义的状态码:

  • 1000

    • 1000 表明这是一个正常的关闭,表示连接已经圆满完成了其工作。
  • 1001

    • 1001 表明一端是即将关闭的,比如服务端将关闭或者浏览器跳转到了其他页面。
  • 1002

    • 1002 表明一端正在因为协议错误而关闭连接。
  • 1003

    • 1003 表明一端因为接收到了无法受理的数据而关闭连接(比如只能处理文本的一端接收到了一个二进制的消息)
  • 1004

    • 保留的。特定的含义会在以后定义。
  • 1005

    • 1005 是一个保留值,并且必须不可以作为关闭帧的状态码。它的存在意义就是应用程序可以使用其表示帧中没有包含状态码。
  • 1006

    • 1006 这是一个保留值,并且必须不可以作为关闭帧的状态码。它的存在意义就是如果连接非正常关闭而应用程序需要一个状态码时,可以使用这个值。
  • 1007

    • 1007 表明一端接收到的消息内容与之标记的类型不符而需要关闭连接(比如文本消息中出现了非 UTF-8 的内容)
  • 1008

    • 1008 表明了一端接收到的消息内容违反了其接收消息的策略而需要关闭连接。这是一个通用的状态码,可以在找不到其他合适的状态码时使用此状态码,或者希望隐藏具体与接收端的哪些策略不符时(比如 1003 和 1009)。
  • 1009

    • 1009 表明一端接收了非常大的数据而其无法处理时需要关闭连接。
  • 1010

    • 1010 表明了客户端希望服务端协商一个或多个扩展,但是服务端在返回的握手信息中包含协商信息。扩展的列表必须出现在其发送给服务端的关闭帧的 /reason/ 中。注意这个状态码并不被服务端使用。
  • 1011

    • 1011 表明了一端遇到了异常情况使得其无法完成请求而需要关闭连接。
  • 1015

    • 1015 是一个保留值,并且它必须不可以作为状态码在关闭帧中使用,在应用程序需要一个状态码去表明执行 TLS 握手失败时,可以使用它(比如服务端的证书没有通过验证)。

7.4.2 保留的状态码区间

  • 0-999

    在 0-999 之间的状态码是不被使用的

  • 1000-2999

    在 1000-2999 之间的状态码是本协议保留的,并且扩展可以在其公开的技术说明中使用。

  • 3000-3999

    在 3000-3999 之间的状态码是为库、框架、应用程序保留的。这些状态码可以直接通过 IANA 进行注册。状态码的具体表示意义为在本协议中定义。

  • 4000-4999

    在 4000-4999 之间的状态码是为了私有使用而保留的,因此不可以被注册。相应状态码的使用及其意义可以在 WebSocket 应用程序之间事先商议好。这些状态码的意义在本协议中未定义。

错误处理

8.1 处理编码错误的 UTF-8 数据

当一端在以 UTF-8 编码解释接收到的数据,但是发现其实不是有效的 UTF-8 编码时,一端必须标记 WebSocket 连接为失败。这个规则适用于握手以及随后的数据传输阶段。

9. 扩展

在这份技术说明中,客户端是可以请求使用扩展的,并且服务端可以受理客户端请求的扩展中的一个或者所有扩展。服务端响应的扩展必须属于客户端请求的扩展列表。如果扩展协商中包含了相应的扩展参数,那么参数的选择和应用必须按照具体的扩展的技术说明中描述的方式。

9.1 扩展协商

客户端通过包含 |Sec-WebSocket-Extensions| 去请求扩展,此字段名遵循普通的 HTTP 头字段的规则 [RFC2616], Section 4.2](https://tools.ietf.org/html/rfc2616#section-4.2),其内容的形式经由下面的 ABNF RFC2616 表达式给出定义。注意,着一节中的 ABNF 语法规则遵循 RFC2616,包括了 “隐含的 *LWS 规则”。如果一端接收到的值不符合下面的 ABNF,那么接收端必须立刻标记 WebSocket 连接为失败。

Sec-WebSocket-Extensions = extension-list
extension-list = 1#extension
extension = extension-token *( ";" extension-param )
extension-token = registered-token
registered-token = token
extension-param = token [ "=" (token | quoted-string) ]
    ;When using the quoted-string syntax variant, the value
    after quoted-string unescaping MUST conform to the
    ;'token' ABNF.

注意,和其他的 HTTP 头字段一样,这些头字段也可以分隔成多行,或者由多行合并。因此下面的两个是等价的:

Sec-WebSocket-Extensions: foo
Sec-WebSocket-Extensions: bar; baz=2

等价于

Sec-WebSocket-Extensions: foo, bar; baz=2

任何的 extension-token 比如使用已注册的 token(见第 11.4 节)。为扩展提供的参数比如遵循相应扩展的定义。注意,客户端只是提供它希望使用的扩展,除非服务端从中选择了一个或多个表明其也希望使用,否则客户端不可以私自的使用。

注意,扩展的在列表中顺序是重要的。多个扩展之间的交互方式,可能在具体定义了扩展的文档中进行了描述。如果没有定义描述了多个扩展之间应该如何交互,那么排在靠前位置的扩展应该最先被考虑使用。在服务端响应中列出的扩展将是连接实际将会使用的扩展。扩展之间修改数据或者帧的操作顺序,应该假设和扩展在服务端握手响应中的扩展列表中出现的顺序相同。

比如,如果有两个扩展 “foo” 和 “bar”,并且在服务端发送的 |Sec-WebSocket-Extensions| 的值为 “foo, bar”,那么对数据的操作整体来看就是 bar(foo(data)),对于数据或者帧的修改过程看起来像是 “栈 stack”。

一个关于受理扩展头字段的非规范化的例子:

Sec-WebSocket-Extensions: deflate-stream
Sec-WebSocket-Extensions: mux; max-channels=4; flow-control, deflate-stream
Sec-WebSocket-Extensions: private-extension

服务端受理一个或者多个扩展,通过 |Sec-WebSocket-Extensions| 头字段包含一个或者多个来自客户端请求中的扩展。扩展参数的解释,以及服务端如何正确响应客户端的参数,都在各自扩展的定义中描述。

9.2 已知的扩展

扩展提供了一个插件的机制,以提供额外的协议功能。这份文档没有定义任何的扩展,但是实现时可以使用独立定义在其他文档的扩展。

10. 安全考虑

这一节描述了一些 WebSocket 协议在使用中需要注意的问题。问题被分成了不同的小节。

10.1 非浏览器客户端

WebSocket 可以抵御运行在被信任的应用程序(比如浏览器)中的恶意 Javascript 脚本,比如,通过检查 |Origin| 头字段。不过当面对具有更多功能的客户端时就不能采用此方法了(检查 |Origin| 头字段)。

这份协议可以适用于运行在 web 页面中的脚本,也可以直接被主机所使用。那些主机可以因为自身的目的发送一个伪造的 |Origin| 头字段,以此迷惑服务器。服务端因此服务器不应该信息任何的客户端输入。

例子:如果服务端使用了客户端的 SQL 查询语句,所有的输入文本在提交到 SQL 服务器之前必须进行跳脱操作(escape),减少服务端被 SQL 注入的风险。

10.2 Origin 的考虑

服务端不必接收来自互联网的所有请求,可以仅仅受理包含特定源的请求。如果请求的源不符合服务端的接收范围,那么服务端应该在对客户端的握手响应中包含状态码 “403 Forbidden”。

|Origin| 的作用是可以预防来自运行在可信任的客户端中的 Javascript 的恶意攻击。客户端本身可以连接到服务器,通过 |Origin| 的机制决定是否将通信的权限交给 Javascript 应用。这么做的目的不是针对非浏览器的连接,而是杜绝运行在被信任的浏览器可能的潜在威胁 - Javascript 脚本伪造 WebSocket 连接。

10.3 针对基础设施的攻击

除了一端的终节点会收到攻击之外,基础设施中的其他部分,比如代理,也可能会收到攻击。

针对代理的攻击实际上是针对那些在实现上有缺陷的代理服务器,有缺陷的代理服务器的工作方式类似:

  1. 首先你通过 Socket 的方式和 IP 为 2.2.2.2 的服务器建立连接,连接是经由代理的。
  2. 在连接建立完成后,你发送了类似下面的文本:
GET /script.js HTTP/1.1
Host: target.com

(更多更深入的描述见 Talking

这段文本首先是传到代理服务器的,代理服务器正确的工作方式是应该将此文本直接转发给 IP 为 2.2.2.2 的服务器。可是,有缺陷的代理会认为这是一个 HTTP 请求,需要采用 HTTP 代理的机制,进而访问了 target.com 并获取了 /script.js。

这种错误的工作方式并不是你所期望的。但是不可能一一检查网络中所有可能存在此问题的代理,所以最好的方式就是将客户端发送的内容都进行掩码操作,这样就不会出现那种让有缺陷的代理服务器产生迷惑的内容了。

10.4 特定实现的限制

在协议实现中,可能会有一些客观的限制,比如特定平台的限制,这些限制与帧的大小或者所有帧合并后的消息的大小相关(比如,恶意的终节点可以通过发送单个很大的帧(2**60),或者发送很多很小的帧但是这些帧组成的消息非常大,以此来耗尽另一方的资源)。因此在实现中,一端应该强制使用一些限制,限制帧的大小,以及许多帧最后组成的消息的大小。

10.5 WebSocket 客户端认证

这份协议没有规定任何方式可被用于服务端在握手期间对客户端进行认证。WebSocket 服务端可以使用任何在普通 HTTP 服务端中使用的对客户端的认证方式,比如 cookie,HTTP 认证,或者 TLS 认证。

10.6 连接的保密性和完整性

WebSocket 协议的保密性和完整性是通过将其运行在 TLS 上达到的。WebSocket 实现必须支持 TLS 并在需要的时候使用它。

对于使用 TLS 的连接,TLS 提供的大部分好处都是基于 TLS 握手阶段协商的算法的强度。比如,一些 TLS 加密算法没有保证信息的保密性。为了使安全达到合适的程度,客户端应该只使用高强度的 TLS 算法。W3C.REC-wsc-ui-20100812 具体讨论了什么是高强度的 TLS 算法,RFC5246 的附录 A.5 和 附录 D.3 提供了一些指导意见。

10.7 处理错误数据

客户端和服务端接收的数据都必须经过验证。如果在任意时间点上,一端接收到了无法理解的或者违反标准的数据,或者发现了不安全的数据,或者在握手期间接收到了非期望的值(比如错误的路径或者源),则可以关闭 TCP 连接。如果接收到无效数据时 WebSocket 连接已经建立,那么一端在关闭 WebSocket 连接之前,应该向另一端发送一个带有适当的状态码的关闭帧。通过使用具有适当状态码的关闭帧,可以帮助定位问题。如果在握手期间接收到了无效的数据,那么服务端应该返回适当的 HTTP 状态码 RFC2616

一个典型的安全问题就是当发送的数据采用了错误的编码时。这份协议中规定了,文本数据包含的必须是 UTF-8 编码的数据。应用程序需要通过一个长度去确定帧序列的传输何时结束,但是这个长度往往在事先不好确定(碎片化的消息)。这就给检查文本消息是否采用了正确的编码带来了困难,因为必须等到消息的所有碎片帧都接受完成了,才可以检查它们组成的消息的编码是否正确。不过如果不检查编码的话,就不能确保接收的数据可以被正确的解释,并会带来潜在的安全问题。

10.8 在 WebSocket 握手中采用 SHA-1

这份文档中描述的 WebSocket 握手并不依赖于 SHA-1 算法的安全属性,比如抗碰撞性或者在 RFC4270 中描述的 second pre-image attack。

11~14

略,见 原文

Made with gadget